Skip to content

feat: MarkdownHeaderSplitter#9660

Open
OGuggenbuehl wants to merge 92 commits intodeepset-ai:mainfrom
OGuggenbuehl:feature/md-header-splitter
Open

feat: MarkdownHeaderSplitter#9660
OGuggenbuehl wants to merge 92 commits intodeepset-ai:mainfrom
OGuggenbuehl:feature/md-header-splitter

Conversation

@OGuggenbuehl
Copy link

@OGuggenbuehl OGuggenbuehl commented Jul 29, 2025

Proposed Changes:

Implement MarkdownHeaderSplitter to split Documents written in .md based on their headers

How did you test it?

unit tests

Checklist

  • I have read the contributors guidelines and the code of conduct
  • I have updated the related issue with new insights and changes
  • I added unit tests and updated the docstrings
  • I've used one of the conventional commit types for my PR title: fix:, feat:, build:, chore:, ci:, docs:, style:, refactor:, perf:, test: and added ! in case the PR includes breaking changes.
  • I documented my code
  • I ran pre-commit hooks and fixed any issue

@CLAassistant
Copy link

CLAassistant commented Jul 29, 2025

CLA assistant check
All committers have signed the CLA.

@github-actions github-actions bot added topic:tests type:documentation Improvements on the docs labels Jul 29, 2025
@OGuggenbuehl OGuggenbuehl changed the title Feature/md header splitter feat:MarkdownHeaderSplitter Jul 29, 2025
@sjrl sjrl self-assigned this Aug 19, 2025
@sjrl
Copy link
Contributor

sjrl commented Aug 19, 2025

@OGuggenbuehl definitely looks like an interesting approach! I've left an initial set of comments, but to further review I'd appreciate if you could add a set of tests like the ones we have for the DocumentSplitter https://github.com/deepset-ai/haystack/blob/main/test/components/preprocessors/test_document_splitter.py

This will help me be able to review the actual algorithm for splitting since it's easier to understand with examples.

@sjrl sjrl changed the title feat:MarkdownHeaderSplitter feat: MarkdownHeaderSplitter Aug 27, 2025
@OGuggenbuehl OGuggenbuehl force-pushed the feature/md-header-splitter branch from 61a8396 to bcbbf9a Compare September 16, 2025 13:57
@sjrl
Copy link
Contributor

sjrl commented Sep 18, 2025

Thanks for your continued work on this @OGuggenbuehl!

Some general comments. Could you:

  • Add a release note for this PR following the instructions here
  • Could you make sure to include our license header to the beginning of each file you've added. You can find an example of the license header here
  • Please make sure to sign the CLA agreement (docs about it here) from this comment
  • If you haven't already please also set up pre-commit hooks using pre-commit install. You can find more info about that in this section of our contribution guidelines.
  • Also in the future feel free to open branches directly in Haystack instead of using a fork. This makes it slightly easier to pull down your code to review locally.

@coveralls
Copy link
Collaborator

coveralls commented Sep 19, 2025

Pull Request Test Coverage Report for Build 19816136481

Details

  • 0 of 0 changed or added relevant lines in 0 files are covered.
  • 3 unchanged lines in 1 file lost coverage.
  • Overall coverage remained the same at 92.189%

Files with Coverage Reduction New Missed Lines %
core/pipeline/async_pipeline.py 3 65.88%
Totals Coverage Status
Change from base Build 19775495543: 0.0%
Covered Lines: 14174
Relevant Lines: 15375

💛 - Coveralls

@OGuggenbuehl OGuggenbuehl marked this pull request as ready for review September 19, 2025 16:05
@OGuggenbuehl OGuggenbuehl requested review from a team as code owners September 19, 2025 16:05
@OGuggenbuehl OGuggenbuehl removed the request for review from a team September 19, 2025 16:05
@OGuggenbuehl OGuggenbuehl force-pushed the feature/md-header-splitter branch from c7264e6 to ad155cc Compare December 1, 2025 08:28
# SPDX-License-Identifier: Apache-2.0

import re
from typing import Literal, Optional
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies we have moved to using python 3.10 types since this PR is opened. So if you could drop Optional and instead use | None instead that would be great!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you could also update your branch with current main then the formatting scripts and tests should catch this change for you

Comment on lines +260 to +266
current_page = doc.meta.get("page_number", 1) if doc.meta else 1
total_pages = doc.content.count(self.page_break_character) + 1
logger.debug(
"Processing page number: {current_page} out of {total_pages}",
current_page=current_page,
total_pages=total_pages,
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct me if I'm wrong but this doesn't sound quite right. The incoming document is usually a converted PDF file from a converter that hasn't yet been split. So this would mean the page_number probably doesn't exist yet in the meta data.

Either way the message "Processing page number: {current_page} out of {total_pages}" I think can be off for a few reasons. We are not just processing current_page we are processing all the pages right?

Also currently you don't offset total pages by current page so we could end up with message like "Processing page number: 10 out of 2" if page_number from meta was equal to 10 and this doc only had one page_break_character in it right?

Comment on lines +111 to +112
if not self.keep_headers and content.startswith("\n"):
content = content[1:] # remove leading newline if headers not kept
Copy link
Contributor

@sjrl sjrl Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say let's drop this update and keep the leading newline character. We should utilize a DocumentCleaner after this splitter if we want to clean up this kind of leading and trailing whitespace type characters

Comment on lines +119 to +124
# skip splits w/o content
if not content.strip(): # this strip is needed to avoid counting whitespace as content
# add as parent for subsequent headers
active_parents = [h for h in header_stack[: level - 1] if h is not None]
active_parents.append(header_text)
if self.keep_headers:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can produce an unwanted edge-case which is if keep_headers is True then they will be added to the content below on line 136 but we never reach there if the content is empty since we will hit the continue on 127. So currently it seems to me that this if self.keep_headers: doesn't do anything since once we reach here we will always skip this match anyways


# Check that content is present and correct
# Test first split
header1_doc = split_docs[0]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

never mind I see it's there already

Comment on lines +387 to +388
def test_page_break_handling_with_multiple_headers():
text = "# Header\nFirst page\f Second page\f Third page"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the name of the test! But I don't see multiple headers. Could we update the example to use multiple headers and sub-headers?

I think ideally you could make a copy of the sample_text fixture that also includes page breaks.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is hard to defend – I think this is an artifact of me reworking and merging tests. I'll make sure to make this one consistent with its name, my bad!

assert subheader123_doc.content == "Content under header 1.2.3."


def test_split_parentheaders(sample_text):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove this test now since we test the parent_headers in test_split_without_headers and test_basic_split

Comment on lines +172 to +173
headers = {doc.meta["header"] for doc in split_docs}
assert {"Another Header", "H1", "H2"}.issubset(headers)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not use these vague checks. Let's explicitly check that the expected doc has the expected header. So like

split_docs[X] == "Another Header"
split_docs[Y] == "H1"
...

docs = [Document(content=text)]
result = splitter.run(documents=docs)
split_docs = result["documents"]
assert len(split_docs) == 24
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also run the same checks for this output

    for i in range(1, len(split_docs)):
        prev_doc = split_docs[i - 1]
        curr_doc = split_docs[i]
        if prev_doc.meta["header"] == curr_doc.meta["header"]:  # only check overlap within same header
            prev_words = prev_doc.content.split()
            curr_words = curr_doc.content.split()
            assert prev_words[-2:] == curr_words[:2]

docs = [Document(content=text)]
result = splitter.run(documents=docs)
split_docs = result["documents"]
assert len(split_docs) == 21
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we also add checks that the split_ids are as expected?

assert len(split_docs[0].content.split()) == 4 # "# Header" + 2 words
assert len(split_docs[1].content.split()) == 3 # 3 words (split_length)
assert len(split_docs[2].content.split()) == 3 # 3 words (split_length)
assert len(split_docs[3].content.split()) == 2 # 2 words (meets threshold)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also add split_id checks

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we also update the assertions to be string comparisons instead of lengths? That would make it easier to see if something went wrong.

assert len(split_docs) == 3
assert len(split_docs[0].content.split()) == 3 # 3 words
assert len(split_docs[1].content.split()) == 3 # 3 words
assert len(split_docs[2].content.split()) == 4 # 4 words (due to threshold, not possible to split 3-1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's also add split_id checks here

auto-merge was automatically disabled January 30, 2026 13:31

Head branch was pushed to by a user without write access

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants